Skip to content

Flatten eligible loop-body lexicals into the pooled loop environment#2610

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-loop-body-lexical-flattening
Jul 9, 2026
Merged

Flatten eligible loop-body lexicals into the pooled loop environment#2610
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-loop-body-lexical-flattening

Conversation

@lahma

@lahma lahma commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What

for (let ...) { const z = ...; ... } paid the block-environment ceremony on every iteration:
unpark/attach the cached env, two UpdateLexicalEnvironment swaps, park + ResetSlots. When the
body block's bindings are slot-eligible (BlockState.SlotNames non-null already implies all-lexical
declarations, ≤16 bindings and no escaping closures), contain no using declarations and don't
shadow the loop header's names, the body's slot layout is now concatenated onto the pooled loop
environment. Each iteration re-establishes the body range's TDZ with a handful of Binding struct
copies and runs the block contents in place — no environment work.

Under a debugger the normal block path runs instead (its fresh environment shadows the unused
flattened slots, so the fallback is semantically inert); suspendable frames never take the pooled
path to begin with.

Results

A/B (default job, adjacent runs):

row before after
stopwatch-modern driver (±0.7%) 185.3 ms/iter 175.2 ms/iter −5.4%
StopwatchBenchmark modern Execute 175.0 ms 165.4 ms −5.5%
StopwatchBenchmark modern Prepared 168.5 ms 159.0 ms −5.6%
StopwatchBenchmark classic (guard) 152.2 / 154.5 ms 152.4 / 150.0 ms flat
stopwatch classic driver (guard) 168.3 ms/iter 168.5 ms/iter flat

ForBencmark guard rows resolved flat via --launchCount 5 (ReenteredInnerLetLoop 5-launch mean
48.16 ms ≡ base 48.09 — its body has no lexicals, so flattening never arms there). Allocations
byte-identical everywhere; the modern rows drop a Gen0 bucket.

Tests

The stopwatch-modern shape, per-iteration const freshness, TDZ re-established across
continue-skipped declarations, header-name shadowing (declined), capturing bodies (declined via
escape analysis), throw-in-body state, and using-declaration dispose ordering (declined).

Gating

All-TFM build ✅ · Jint.Tests ×2 ✅ · PublicInterface ×2 ✅ · Test262 99,426/0 ✅ (re-run in full
after rebasing onto #2607, which touches adjacent break/label semantics; one Atomics.waitAsync
wall-clock flake in an earlier run passed 8/8 in isolation)

🤖 Generated with Claude Code

for (let ...) { const z = ...; ... } paid the block-environment
ceremony on every iteration: unpark/attach the cached env, two
UpdateLexicalEnvironment swaps, park + ResetSlots. When the body
block's bindings are slot-eligible (BlockState.SlotNames non-null
already implies all-lexical declarations, <= 16 bindings and no
escaping closures), contain no using declarations and don't shadow the
loop header's names, the body's slot layout is now concatenated onto
the pooled loop environment. Each iteration re-establishes the body
range's TDZ with a handful of Binding struct copies and runs the block
contents in place (JintBlockStatement.ExecuteFlattenedContents - the
statement list or single statement with the usual error protocol, no
environment work).

Under a debugger the normal block path runs instead; its fresh
environment shadows the unused flattened slots, so the fallback is
semantically inert. Suspendable frames never take the pooled path to
begin with.

A/B (default job, adjacent runs):

| row                          | before    | after     |        |
|------------------------------|-----------|-----------|--------|
| stopwatch-modern driver      | 185.3 ms  | 175.2 ms  | -5.4%  |
| StopwatchBenchmark modern Execute  | 175.0 ms | 165.4 ms | -5.5% |
| StopwatchBenchmark modern Prepared | 168.5 ms | 159.0 ms | -5.6% |
| StopwatchBenchmark classic (both)  | 152.2/154.5 | 152.4/150.0 | flat |
| stopwatch classic driver     | 168.3 ms  | 168.5 ms  | flat   |

ForBencmark guard rows resolved flat via --launchCount 5
(ReenteredInnerLetLoop 5-launch mean 48.16 ms == base 48.09; its body
has no lexicals, so flattening never arms there). Allocations
byte-identical everywhere; modern rows drop a Gen0 bucket.

Tests pin the stopwatch-modern shape, per-iteration const freshness,
TDZ re-established across continue-skipped declarations, header-name
shadowing (declined), capturing bodies (declined via escape analysis),
throw-in-body state, and using-declaration dispose ordering (declined).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) July 9, 2026 11:51
@lahma
lahma merged commit 011117e into sebastienros:main Jul 9, 2026
4 checks passed
@lahma
lahma deleted the perf-loop-body-lexical-flattening branch July 9, 2026 12:01
lahma added a commit that referenced this pull request Jul 10, 2026
…ations (#2623)

The tight loop previously admitted only expression-statement bodies, so
the stopwatch-shaped body (var decl + 4-way else-if chain calling tiny
closures + two member-read var decls) paid full per-statement ceremony:
PrepareFor, Completion materialization, statement-list bookkeeping and
the nested if suspension probes, 391k times per benchmark op.

The body shape predicate now admits expression statements, var/let/const
declarations (using/await-using excluded) and if/else chains over such
statements, including declaration-free brace blocks. Control flow that
routes completions (break/continue/return/labels/loops/switch/try),
other declarations and AnnexB function-declaration branches still
disqualify structurally, so every admitted completion is Normal by
construction and the lane needs no completion routing.

Dispatch happens through a new virtual JintStatement.ExecuteDiscarded
(default: full Execute) with lean overrides for expression statements,
if statements (unboxed GetBooleanValue test, no suspension-range checks,
no UpdateEmpty), variable declarations (slot lane / cached-global route
already end in Empty completions) and declaration-free nested blocks.
Deferred errors keep the existing per-statement Engine._error poll
protocol; statements after a deferred error do not run.

Bodies with lexical declarations engage only when #2610 flattening is
active: the pooled loop env carries the body slots and the tight loop
re-establishes their TDZ per iteration exactly like the generic
flattened arm. Rider: JintConditionalExpression now takes the unboxed
GetBooleanValue path for its test in non-suspendable frames.


Claude-Session: https://claude.ai/code/session_01BY8HoKam5H8vTPn1bMY2Hv

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lahma pushed a commit that referenced this pull request Jul 18, 2026
…#2709)

Loop-body lexical flattening (#2610) folds an eligible body block's let/const
bindings into the pooled loop environment. The eligibility gate (NamesOverlap)
only checked body names against the header's declared names, missing the case
where a body name shadows an outer binding a header expression references:

    for (let l = r; l < o; l++) { let r = e[l], o = t.get(r); ... }

The header's r/o resolve to outer bindings, but flattening folded the body's
r/o slots into the loop env, so the header read them while still uninitialized
-> "r has not been initialized". V8 runs it fine. This is the shape in
Turbopack's module-factory runtime, so it aborted Turbopack/Next.js renders.

Add HeaderReferencesAnyBodyName, declining to flatten when any body slot name
appears as an identifier in init/test/update. Over-approximates in the safe
direction, mirroring EnvironmentEscapeAstVisitor.ClosureReferencesAny. The scan
runs once at construction, so there is no per-iteration cost, and flattening
still applies to headers that reference no shadowed name.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant